master @ 284 LINES
[ HISTORY ] [ UP ]
┌─ ASTRO ────────────────────────────────────────────────────────────────────┐
│ --- │
│ import PageLayout from "../../../../layouts/PageLayout.astro"; │
│ import PlayerSearch from "../../../../components/PlayerSearch/PlayerSearch.astro"; │
│ import LeaderboardTypeNav from "../../../../components/Leaderboard/LeaderboardType │
│ Nav/LeaderboardTypeNav.astro"; │
│ import LeaderboardScopeFilter from "../../../../components/Leaderboard/Leaderboard │
│ ScopeFilter/LeaderboardScopeFilter.astro"; │
│ import KpiCard from "../../../../components/Stats/KpiCard/KpiCard.astro"; │
│ import CompletionTiers from "../../../../components/Stats/CompletionTiers/Completi │
│ onTiers.astro"; │
│ import SpecDistributionChart from "../../../../components/Stats/SpecDistributionCh │
│ art/SpecDistributionChart.astro"; │
│ import DungeonDistributionChart from "../../../../components/Stats/DungeonDistribu │
│ tionChart/DungeonDistributionChart.astro"; │
│ import ActivityChart from "../../../../components/Stats/ActivityChart/ActivityChar │
│ t.astro"; │
│ import type { StatsJSON, StatsScope } from "../../../../lib/types"; │
│ │
│ export const prerender = false; │
│ │
│ // Parse URL params: /challenge-mode/(season{N}|all-time)/stats/{region} │
│ const seasonParam = (Astro.params.season || "season3").toLowerCase(); │
│ let seasonNumber: number; │
│ let seasonKey: string; │
│ if (seasonParam === "all-time") { │
│ seasonNumber = 0; │
│ seasonKey = "all_time"; │
│ } else { │
│ const seasonMatch = seasonParam.match(/^season(\d+)$/); │
│ seasonNumber = seasonMatch ? parseInt(seasonMatch[1], 10) : 3; │
│ seasonKey = `season_${seasonNumber}`; │
│ } │
│ │
│ const VALID_REGIONS = ["global", "us", "eu", "kr", "tw"] as const; │
│ const rawRegion = (Astro.params.region || "global").toLowerCase(); │
│ const region = (VALID_REGIONS as readonly string[]).includes(rawRegion) │
│ ? rawRegion │
│ : "global"; │
│ │
│ let stats: StatsJSON | null = null; │
│ try { │
│ const response = await fetch(`${Astro.url.origin}/api/stats.json`); │
│ if (response.ok) { │
│ stats = (await response.json()) as StatsJSON; │
│ } │
│ } catch (error) { │
│ console.error("[Stats] Failed to load stats.json:", error); │
│ } │
│ │
│ // Resolve the active scope. Falls back to all_time if the requested season key │
│ // isn't present in the data (e.g. user typed a future season number). │
│ const regionScopes = stats?.scopes[region] ?? null; │
│ const scope: StatsScope | null = │
│ (regionScopes && (regionScopes[seasonKey] ?? regionScopes.all_time)) || null; │
│ │
│ const REGION_LABELS: Record<string, string> = { │
│ global: "Global", │
│ us: "US", │
│ eu: "EU", │
│ kr: "KR", │
│ tw: "TW", │
│ }; │
│ // labels come from the data so new seasons need no frontend change │
│ const SEASON_LABELS: Record<string, string> = { all_time: "All Time" }; │
│ if (stats) { │
│ for (const regionScope of Object.values(stats.scopes)) { │
│ for (const key of Object.keys(regionScope)) { │
│ if (!(key in SEASON_LABELS)) { │
│ const match = key.match(/^season_(\d+)$/); │
│ if (match) { │
│ SEASON_LABELS[key] = `Season ${match[1]}`; │
│ } │
│ } │
│ } │
│ } │
│ } │
│ │
│ const generatedAt = stats?.generated_at ? new Date(stats.generated_at) : null; │
│ const scopeLabel = `${REGION_LABELS[region] ?? region.toUpperCase()} - ${SEASON_LA │
│ BELS[seasonKey] ?? seasonKey}`; │
│ // unique chart id so multiple charts on the page (or future panels) don't collide │
│ const panelId = `${region}_${seasonKey}`; │
│ --- │
│ │
│ <PageLayout │
│ title={`Challenge Mode Stats - ${scopeLabel}`} │
│ description="Aggregate stats across WoW MoP Challenge Mode runs - completion tie │
│ rs, spec popularity, weekly activity." │
│ > │
│ <main class="stats-page"> │
│ { │
│ !stats && ( │
│ <div class="stats-page__error"> │
│ <h2>Couldn't load stats data</h2> │
│ <p> │
│ The stats.json file is missing or unreachable. Try regenerating it │
│ via <code>ookstats generate stats</code>. │
│ </p> │
│ </div> │
│ ) │
│ } │
│ │
│ { │
│ stats && ( │
│ <> │
│ <header class="stats-page__hero"> │
│ <h1>Challenge Mode Stats</h1> │
│ {generatedAt && ( │
│ <span class="stats-page__updated"> │
│ Data updated{" "} │
│ {generatedAt.toLocaleString("en-US", { │
│ month: "short", │
│ day: "numeric", │
│ hour: "2-digit", │
│ minute: "2-digit", │
│ })} │
│ </span> │
│ )} │
│ </header> │
│ │
│ <PlayerSearch /> │
│ │
│ <LeaderboardTypeNav │
│ currentTab="stats" │
│ currentRegion={region} │
│ currentSeason={seasonNumber} │
│ /> │
│ │
│ <div class="cm-filters"> │
│ <LeaderboardScopeFilter │
│ leaderboardType="stats" │
│ currentRegion={region} │
│ currentSeason={seasonNumber} │
│ /> │
│ </div> │
│ │
│ {scope ? ( │
│ <section │
│ class="stats-page__panel" │
│ data-region={region} │
│ data-scope-panel={seasonKey} │
│ > │
│ <div class="stats-page__kpis"> │
│ <KpiCard label="Total runs" value={scope.total_runs} /> │
│ <KpiCard │
│ label="Characters in runs" │
│ value={scope.total_players} │
│ /> │
│ <KpiCard │
│ label="9/9 Characters" │
│ value={scope.nine_of_nine_players} │
│ sublabel={ │
│ scope.total_players > 0 │
│ ? `${((scope.nine_of_nine_players / scope.total_players) * 1 │
│ 00).toFixed(2)}% of all characters` │
│ : "" │
│ } │
│ /> │
│ </div> │
│ │
│ <section class="stats-page__section"> │
│ <header class="stats-page__section-header"> │
│ <h2>Completion Percentiles</h2> │
│ </header> │
│ <CompletionTiers │
│ gold={scope.completion_tiers["9_of_9_gold"]} │
│ platinum={scope.completion_tiers["9_of_9_platinum"]} │
│ title={scope.completion_tiers["9_of_9_title"]} │
│ /> │
│ </section> │
│ │
│ <section class="stats-page__section"> │
│ <header class="stats-page__section-header"> │
│ <h2>Activity Over Time</h2> │
│ </header> │
│ <ActivityChart data={scope.weekly_activity} scope={panelId} /> │
│ </section> │
│ │
│ <section class="stats-page__section"> │
│ <header class="stats-page__section-header"> │
│ <h2>Spec Distribution</h2> │
│ </header> │
│ <SpecDistributionChart │
│ buckets={scope.spec_counts} │
│ scope={panelId} │
│ /> │
│ </section> │
│ │
│ <section class="stats-page__section"> │
│ <header class="stats-page__section-header"> │
│ <h2>Dungeon Distribution</h2> │
│ </header> │
│ <DungeonDistributionChart │
│ buckets={scope.spec_counts} │
│ scope={panelId} │
│ /> │
│ </section> │
│ </section> │
│ ) : ( │
│ <div class="stats-page__error"> │
│ <h2>No data for {scopeLabel}</h2> │
│ <p>Try another season or region.</p> │
│ </div> │
│ )} │
│ </> │
│ ) │
│ } │
│ </main> │
│ </PageLayout> │
│ │
│ <style lang="scss"> │
│ @use "../../../../styles/core/tokens" as *; │
│ │
│ // Match CM page wrapper (.leaderboard-page on /challenge-mode/* pages). │
│ .stats-page { │
│ max-width: 1200px; │
│ margin: 0 auto; │
│ padding: 20px; │
│ } │
│ │
│ .stats-page__error { │
│ background: var(--bg-secondary); │
│ border: 1px solid var(--border-color); │
│ border-radius: $radius-lg; │
│ padding: $spacing-2xl; │
│ text-align: center; │
│ color: var(--text-secondary); │
│ code { │
│ background: var(--bg-primary); │
│ padding: 2px 6px; │
│ border-radius: $radius-sm; │
│ color: var(--highlight-color); │
│ } │
│ } │
│ │
│ .stats-page__hero { │
│ display: flex; │
│ align-items: baseline; │
│ justify-content: space-between; │
│ gap: $spacing-lg; │
│ flex-wrap: wrap; │
│ margin: 8px 0 16px 0; │
│ │
│ h1 { │
│ margin: 0; │
│ font-size: 2em; │
│ color: var(--text-primary); │
│ font-weight: $font-weight-bold; │
│ } │
│ } │
│ │
│ .stats-page__updated { │
│ color: var(--text-tertiary); │
│ font-size: $font-size-sm; │
│ } │
│ │
│ .stats-page__panel { │
│ display: flex; │
│ flex-direction: column; │
│ gap: $spacing-2xl; │
│ } │
│ │
│ .stats-page__kpis { │
│ display: grid; │
│ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); │
│ gap: $spacing-md; │
│ } │
│ │
│ .stats-page__section { │
│ display: flex; │
│ flex-direction: column; │
│ gap: $spacing-md; │
│ } │
│ │
│ .stats-page__section-header h2 { │
│ margin: 0; │
│ font-size: $font-size-2xl; │
│ color: var(--text-primary); │
│ font-weight: $font-weight-semibold; │
│ } │
│ │
│ // mobile overrides at end of stylesheet to win the cascade │
│ @media (max-width: 768px) { │
│ .stats-page { │
│ padding: 15px; │
│ } │
│ │
│ .stats-page__hero h1 { │
│ font-size: 1.5em; │
│ } │
│ } │
│ </style> │
└────────────────────────────────────────────────────────────────────────────────────┘
┌─ ASTRO ──────────────────────────────┐
│ --- │
│ import PageLayout from "../../../../layouts/ │
│ PageLayout.astro"; │
│ import PlayerSearch from "../../../../compon │
│ ents/PlayerSearch/PlayerSearch.astro"; │
│ import LeaderboardTypeNav from "../../../../ │
│ components/Leaderboard/LeaderboardTypeNav/Le │
│ aderboardTypeNav.astro"; │
│ import LeaderboardScopeFilter from "../../.. │
│ /../components/Leaderboard/LeaderboardScopeF │
│ ilter/LeaderboardScopeFilter.astro"; │
│ import KpiCard from "../../../../components/ │
│ Stats/KpiCard/KpiCard.astro"; │
│ import CompletionTiers from "../../../../com │
│ ponents/Stats/CompletionTiers/CompletionTier │
│ s.astro"; │
│ import SpecDistributionChart from "../../../ │
│ ../components/Stats/SpecDistributionChart/Sp │
│ ecDistributionChart.astro"; │
│ import DungeonDistributionChart from "../../ │
│ ../../components/Stats/DungeonDistributionCh │
│ art/DungeonDistributionChart.astro"; │
│ import ActivityChart from "../../../../compo │
│ nents/Stats/ActivityChart/ActivityChart.astr │
│ o"; │
│ import type { StatsJSON, StatsScope } from " │
│ ../../../../lib/types"; │
│ │
│ export const prerender = false; │
│ │
│ // Parse URL params: /challenge-mode/(season │
│ {N}|all-time)/stats/{region} │
│ const seasonParam = (Astro.params.season || │
│ "season3").toLowerCase(); │
│ let seasonNumber: number; │
│ let seasonKey: string; │
│ if (seasonParam === "all-time") { │
│ seasonNumber = 0; │
│ seasonKey = "all_time"; │
│ } else { │
│ const seasonMatch = seasonParam.match(/^se │
│ ason(\d+)$/); │
│ seasonNumber = seasonMatch ? parseInt(seas │
│ onMatch[1], 10) : 3; │
│ seasonKey = `season_${seasonNumber}`; │
│ } │
│ │
│ const VALID_REGIONS = ["global", "us", "eu", │
│ "kr", "tw"] as const; │
│ const rawRegion = (Astro.params.region || "g │
│ lobal").toLowerCase(); │
│ const region = (VALID_REGIONS as readonly st │
│ ring[]).includes(rawRegion) │
│ ? rawRegion │
│ : "global"; │
│ │
│ let stats: StatsJSON | null = null; │
│ try { │
│ const response = await fetch(`${Astro.url. │
│ origin}/api/stats.json`); │
│ if (response.ok) { │
│ stats = (await response.json()) as Stats │
│ JSON; │
│ } │
│ } catch (error) { │
│ console.error("[Stats] Failed to load stat │
│ s.json:", error); │
│ } │
│ │
│ // Resolve the active scope. Falls back to a │
│ ll_time if the requested season key │
│ // isn't present in the data (e.g. user type │
│ d a future season number). │
│ const regionScopes = stats?.scopes[region] ? │
│ ? null; │
│ const scope: StatsScope | null = │
│ (regionScopes && (regionScopes[seasonKey] │
│ ?? regionScopes.all_time)) || null; │
│ │
│ const REGION_LABELS: Record<string, string> │
│ = { │
│ global: "Global", │
│ us: "US", │
│ eu: "EU", │
│ kr: "KR", │
│ tw: "TW", │
│ }; │
│ // labels come from the data so new seasons │
│ need no frontend change │
│ const SEASON_LABELS: Record<string, string> │
│ = { all_time: "All Time" }; │
│ if (stats) { │
│ for (const regionScope of Object.values(st │
│ ats.scopes)) { │
│ for (const key of Object.keys(regionScop │
│ e)) { │
│ if (!(key in SEASON_LABELS)) { │
│ const match = key.match(/^season_(\d │
│ +)$/); │
│ if (match) { │
│ SEASON_LABELS[key] = `Season ${mat │
│ ch[1]}`; │
│ } │
│ } │
│ } │
│ } │
│ } │
│ │
│ const generatedAt = stats?.generated_at ? ne │
│ w Date(stats.generated_at) : null; │
│ const scopeLabel = `${REGION_LABELS[region] │
│ ?? region.toUpperCase()} - ${SEASON_LABELS[s │
│ easonKey] ?? seasonKey}`; │
│ // unique chart id so multiple charts on the │
│ page (or future panels) don't collide │
│ const panelId = `${region}_${seasonKey}`; │
│ --- │
│ │
│ <PageLayout │
│ title={`Challenge Mode Stats - ${scopeLabe │
│ l}`} │
│ description="Aggregate stats across WoW Mo │
│ P Challenge Mode runs - completion tiers, sp │
│ ec popularity, weekly activity." │
│ > │
│ <main class="stats-page"> │
│ { │
│ !stats && ( │
│ <div class="stats-page__error"> │
│ <h2>Couldn't load stats data</h2> │
│ <p> │
│ The stats.json file is missing o │
│ r unreachable. Try regenerating it │
│ via <code>ookstats generate stat │
│ s</code>. │
│ </p> │
│ </div> │
│ ) │
│ } │
│ │
│ { │
│ stats && ( │
│ <> │
│ <header class="stats-page__hero"> │
│ <h1>Challenge Mode Stats</h1> │
│ {generatedAt && ( │
│ <span class="stats-page__updat │
│ ed"> │
│ Data updated{" "} │
│ {generatedAt.toLocaleString( │
│ "en-US", { │
│ month: "short", │
│ day: "numeric", │
│ hour: "2-digit", │
│ minute: "2-digit", │
│ })} │
│ </span> │
│ )} │
│ </header> │
│ │
│ <PlayerSearch /> │
│ │
│ <LeaderboardTypeNav │
│ currentTab="stats" │
│ currentRegion={region} │
│ currentSeason={seasonNumber} │
│ /> │
│ │
│ <div class="cm-filters"> │
│ <LeaderboardScopeFilter │
│ leaderboardType="stats" │
│ currentRegion={region} │
│ currentSeason={seasonNumber} │
│ /> │
│ </div> │
│ │
│ {scope ? ( │
│ <section │
│ class="stats-page__panel" │
│ data-region={region} │
│ data-scope-panel={seasonKey} │
│ > │
│ <div class="stats-page__kpis"> │
│ <KpiCard label="Total runs" │
│ value={scope.total_runs} /> │
│ <KpiCard │
│ label="Characters in runs" │
│ value={scope.total_players │
│ } │
│ /> │
│ <KpiCard │
│ label="9/9 Characters" │
│ value={scope.nine_of_nine_ │
│ players} │
│ sublabel={ │
│ scope.total_players > 0 │
│ ? `${((scope.nine_of_n │
│ ine_players / scope.total_players) * 100).to │
│ Fixed(2)}% of all characters` │
│ : "" │
│ } │
│ /> │
│ </div> │
│ │
│ <section class="stats-page__se │
│ ction"> │
│ <header class="stats-page__s │
│ ection-header"> │
│ <h2>Completion Percentiles │
│ </h2> │
│ </header> │
│ <CompletionTiers │
│ gold={scope.completion_tie │
│ rs["9_of_9_gold"]} │
│ platinum={scope.completion │
│ _tiers["9_of_9_platinum"]} │
│ title={scope.completion_ti │
│ ers["9_of_9_title"]} │
│ /> │
│ </section> │
│ │
│ <section class="stats-page__se │
│ ction"> │
│ <header class="stats-page__s │
│ ection-header"> │
│ <h2>Activity Over Time</h2 │
│ > │
│ </header> │
│ <ActivityChart data={scope.w │
│ eekly_activity} scope={panelId} /> │
│ </section> │
│ │
│ <section class="stats-page__se │
│ ction"> │
│ <header class="stats-page__s │
│ ection-header"> │
│ <h2>Spec Distribution</h2> │
│ </header> │
│ <SpecDistributionChart │
│ buckets={scope.spec_counts │
│ } │
│ scope={panelId} │
│ /> │
│ </section> │
│ │
│ <section class="stats-page__se │
│ ction"> │
│ <header class="stats-page__s │
│ ection-header"> │
│ <h2>Dungeon Distribution</ │
│ h2> │
│ </header> │
│ <DungeonDistributionChart │
│ buckets={scope.spec_counts │
│ } │
│ scope={panelId} │
│ /> │
│ </section> │
│ </section> │
│ ) : ( │
│ <div class="stats-page__error"> │
│ <h2>No data for {scopeLabel}</ │
│ h2> │
│ <p>Try another season or regio │
│ n.</p> │
│ </div> │
│ )} │
│ </> │
│ ) │
│ } │
│ </main> │
│ </PageLayout> │
│ │
│ <style lang="scss"> │
│ @use "../../../../styles/core/tokens" as * │
│ ; │
│ │
│ // Match CM page wrapper (.leaderboard-pag │
│ e on /challenge-mode/* pages). │
│ .stats-page { │
│ max-width: 1200px; │
│ margin: 0 auto; │
│ padding: 20px; │
│ } │
│ │
│ .stats-page__error { │
│ background: var(--bg-secondary); │
│ border: 1px solid var(--border-color); │
│ border-radius: $radius-lg; │
│ padding: $spacing-2xl; │
│ text-align: center; │
│ color: var(--text-secondary); │
│ code { │
│ background: var(--bg-primary); │
│ padding: 2px 6px; │
│ border-radius: $radius-sm; │
│ color: var(--highlight-color); │
│ } │
│ } │
│ │
│ .stats-page__hero { │
│ display: flex; │
│ align-items: baseline; │
│ justify-content: space-between; │
│ gap: $spacing-lg; │
│ flex-wrap: wrap; │
│ margin: 8px 0 16px 0; │
│ │
│ h1 { │
│ margin: 0; │
│ font-size: 2em; │
│ color: var(--text-primary); │
│ font-weight: $font-weight-bold; │
│ } │
│ } │
│ │
│ .stats-page__updated { │
│ color: var(--text-tertiary); │
│ font-size: $font-size-sm; │
│ } │
│ │
│ .stats-page__panel { │
│ display: flex; │
│ flex-direction: column; │
│ gap: $spacing-2xl; │
│ } │
│ │
│ .stats-page__kpis { │
│ display: grid; │
│ grid-template-columns: repeat(auto-fit, │
│ minmax(220px, 1fr)); │
│ gap: $spacing-md; │
│ } │
│ │
│ .stats-page__section { │
│ display: flex; │
│ flex-direction: column; │
│ gap: $spacing-md; │
│ } │
│ │
│ .stats-page__section-header h2 { │
│ margin: 0; │
│ font-size: $font-size-2xl; │
│ color: var(--text-primary); │
│ font-weight: $font-weight-semibold; │
│ } │
│ │
│ // mobile overrides at end of stylesheet t │
│ o win the cascade │
│ @media (max-width: 768px) { │
│ .stats-page { │
│ padding: 15px; │
│ } │
│ │
│ .stats-page__hero h1 { │
│ font-size: 1.5em; │
│ } │
│ } │
│ </style> │
└──────────────────────────────────────────────┘
──────────────────────────────────────────────────────────────────────────────────────
OOKNET
────────────────────────────────────────────────
OOKNET